Skip to content

feat: add client credentials token support for M2M - #138

Open
kishore7snehil wants to merge 3 commits into
feat/obo-token-storagefrom
feat/m2m-client-credentials
Open

kishore7snehil wants to merge 3 commits into
feat/obo-token-storagefrom
feat/m2m-client-credentials

Conversation

@kishore7snehil

@kishore7snehil kishore7snehil commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

📋 Changes

This PR adds get_client_credentials_token() to ApiClient for obtaining machine-to-machine access tokens with the OAuth 2.0 client credentials grant. When a token store is configured, the result is cached so repeat calls within the token's lifetime skip the network round-trip.

✨ Features

  • Client Credentials Token: New get_client_credentials_token(audience, scope=None) method that authenticates with HTTP Basic using the configured client_id and client_secret.
  • M2M Caching: When token_store is set, tokens are cached per tenant, client, audience, and scope set. Store read and write failures are logged and fall back to a fresh exchange.
  • Type Safety: New ClientCredentialsTokenResult TypedDict.

🔧 API Changes

  • New method: ApiClient.get_client_credentials_token()
  • New type: ClientCredentialsTokenResult (TypedDict)
  • New error: GetClientCredentialsTokenError, raised when client credentials are not configured or the token endpoint is missing from discovery metadata

📖 Documentation

  • Updated README.md with a client credentials section
  • Updated EXAMPLES.md with a client credentials example

🧪 Testing

  • This change adds test coverage
  • This change has been tested on the latest version of the platform/language

Contributor Checklist

Comment thread src/auth0_api_python/api_client.py Fixed
@kishore7snehil
kishore7snehil marked this pull request as ready for review October 6, 2026 06:50
@kishore7snehil
kishore7snehil requested a review from a team as a code owner October 6, 2026 06:50
"expires_at": cached["expires_at"],
}
if cached.get("granted_scopes"):
hit["scope"] = cached["granted_scopes"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stored entry keeps only access_token, expires_at and granted_scopes, so a cache hit never returns token_type and drops scope when the granted value is falsy.

A fresh exchange includes both, so the second call for the same audience and scope returns a different dict than the first. The success test reads result["token_type"], which would KeyError on a hit.

Should we store and restore token_type, and gate scope on key presence rather than truthiness so both paths match?

response.status_code
)

token_response = response.json()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is inside a try that only catches httpx errors, but response.json() and the token_response["expires_in"] / ["access_token"] indexing have no guard.

A non JSON body raises ValueError and a missing field raises KeyError, both of which escape instead of becoming an ApiError. expires_in is also used without int() coercion or a negative check.

The sibling exchange method already does all of this. Can we validate access_token is a non empty str and coerce expires_in with int(), raising ApiError on failure?

Comment thread tests/test_api_client.py


@pytest.mark.asyncio
async def test_get_client_credentials_token_success(mock_discovery, api_client_confidential, httpx_mock):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The no token_store happy path (every test uses a client with a store), a different scope for the same audience being a cache miss (the scope part of the key is unverified), the missing token_endpoint branch, and the SDK side expiry guard (the current expiry test relies on the in memory store self expiring).

Can we add these?

token_response = response.json()

expires_in = token_response["expires_in"]
cc_result = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

cc_result = {...} is a plain dict here where ClientCredentialsTokenResult is declared. The hit path and the OBO equivalent both annotate, so cc_result: ClientCredentialsTokenResult = {...} would be consistent.

try:
cached = await self._token_store.get(cache_key)
except Exception as exc:
store_err = TokenStoreError("Token store read failed", cause=exc)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

TokenStoreError(...) is built here only so its .cause can be logged, and logging goes through the root logger. This matches the existing convention so it is consistency only, but logging exc directly and using logging.getLogger(__name__) would be a bit cleaner.

expires_at: int
scope: str
token_type: str

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Only one blank line separates ClientCredentialsTokenResult from the following module level assignment, PEP8 E305 wants two. Purely cosmetic.

@kishore7snehil
kishore7snehil force-pushed the feat/m2m-client-credentials branch from fcb1832 to 6576276 Compare October 8, 2026 04:07
@kishore7snehil
kishore7snehil force-pushed the feat/obo-token-storage branch from 42680cd to d57457d Compare October 8, 2026 04:07
kishore7snehil and others added 3 commits October 8, 2026 09:51
Add an ApiClient method to obtain a client credentials (M2M) access token
for server-to-server calls using the OAuth 2.0 client credentials grant.
The method authenticates via HTTP Basic and caches the result in a
configured token_store keyed by audience and scope set, so a repeat call
within the token's lifetime skips the network round-trip. Add the
ClientCredentialsTokenResult type and GetClientCredentialsTokenError, and
document the method in the README and examples.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Cache the scope Auth0 returned alongside the token and include it on a cache hit, so a hit matches the original exchange.
@kishore7snehil
kishore7snehil force-pushed the feat/obo-token-storage branch from d57457d to 75faa2d Compare October 8, 2026 04:22
@kishore7snehil
kishore7snehil force-pushed the feat/m2m-client-credentials branch from 6576276 to a9668aa Compare October 8, 2026 04:22

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants